/* Navigate ALEKS web pages to extract student progress data.
 * This file is automatically included when you install "extract ALEKS data.user.js".
 *
 * Web page navigation sequence:
 * 1. Nothing happens until you choose "Extract ALEKS assessment data"
 *    from the Greasemonkey menu. Once you do we are "in process".
 *    We store a persistent value named "step" that tells us, when
 *    a new web page loads, that we are in process and which step
 *    we are on.  See wakeUp().
 * 2. We also store a persistent value named "mark" that marks the
 *    time we initiated the current step (navigated to a new web page). 
 *    If too much time has passed since we initiated the step, treat
 *    the step as stale and ignore it, posting a message to the error
 *    console.  See wakeUp().
 * 3. Each ALEKS display loads several frames, each with its own "window"
 *    document. We wake up for each of those loads, but only go to work
 *    if the current document contains the ALEKS user interface and display.
 *    We rely on the presence of ALEKS's class menu to identify our
 *    target frame and document.  See wakeUp() and findClassMenu().
 * 4. If the step name is "midstep" we didn't expect this page load. 
 *    We alert you and quit the run.
 * 5. Otherwise try to execute the step we received, setting the persistent
 *    step name to "midstep" in the meantime.
 * 6. If something unexpected happens along the way, alert the user and quit.
 * 7. Each step except the the very last ends by storing the name of the 
 *    next step and loading the new ALEKS view (loading the URL). 
 *    See nextStep(). 
 * 8. The last step calls done() to clear persistent values.
 *
 * STEPS
 *    extractTeacher - Called by the Greasemonkey menu. Extract data for a
 *              single teacher, selected in a dialog window.
 *    extractThisClass - Called by the Greasemonkey menu. Extract data
 *              for the class currently selected in ALEKS.
 *    captureTeacher - Get the current teacher name. The name appears in
 *              the ALEKS class menu, except for your (the current user's)
 *              name, which appears in the titles of some embedded HTML
 *              documents and in a text item at the top of a class page. Call
 *              the followReportingLink step directly after this, no page load.
 *    followReportingLink - Currently called directly by other steps.
 *              Find and follow the "Reporting" link that appears in
 *              the ALEKS sidebar.
 *    followStudentReportLink - Find and follow the specific report link,
 *              "Massachusetts standards report for a single student:".
 *    studentList - Enumerate the listed students. First, make sure we're
 *              looking at names and not aliases or numbers by following
 *              the "Name" link if it exists. If we do we'll repeat the
 *              same studentList step.
 *              With names displayed, collect them from their UL list
 *              elements and process the next student. We keep track of
 *              the student we're processing in a persistent value named
 *              "student".
 *              Follow the student link to the report page.
 *    studentRecord - Find the student record menu and select an appropriate record.
 *    studentReport - Find and follow the "Open All" link to show individual
 *              ALEKS items.
 *    extract - Extract data for this student and post it.
 *              Call the followReportingLink step directly to continue.
 */

function wakeUp() {
  // Are we in process and is this the right window frame?
  if (step()) {
    // We have a step name. Is it fresh enough?
    if (markNow() - mark() > 10) {
      // More than 10 seconds have passed since this step was marked.
      done('Ignoring stale step ' + step() + ' from ' + markString(), 'silent');  // clear stale variables
    }
    else {
      // We are in process. When this window frame finishes loading, see if
      // it's the one we want to work in.
      GM_log('Watching window ' + window.location.href);
      window.addEventListener('load', findClassMenu, true);
    }
  }
}

// Store a reference to the class selection menu (SELECT element).
var classMenu;  

function findClassMenu0() {
    // Factor out the actual finding.
    // Try to find the SELECT element, which is named "id_main_class".
    // classMenu will be null if we don't find the menu.
    var classMenuXPath = '/descendant::form/descendant::select[attribute::name="id_main_class"]';
    classMenu = window.document.evaluate(classMenuXPath, window.document, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null);
    classMenu = classMenu.singleNodeValue;
}

function findClassMenu() {
    // If this document contains the class selection menu, we're off
    // and running. Otherwise do nothing.
    var thisStep = step();  // store the step name
    findClassMenu0();
    if (classMenu) {
        // We found it. This is our target window frame and document.
        GM_log('Class selection menu found in window ' + window.location.href);
        if (thisStep == 'midstep') {
            var msg = 'The web page reloaded unexpectedly during ALEKS data extraction';
            done(msg, 'exception');  
        }
        else {
            // Execute the named step.
            try {
                GM_log('Performing step ' + thisStep);
                nextStep('midstep');  // mark midstep to catch unexpected page loads (see above)
                stepFunction[thisStep]();  // call the step function
            }
            catch (exception) {
                done(exception, 'exception');
            }
        }
    }
    else {
        // No class menu, this isn't our target window frame.
        GM_log('Ignoring window ' + window.location.href);
    }
}

function logWindows() {
    // Accessory function to display the window frame hierarchy during development.
    GM_log('current window URL: ' + window.location);
    logWindows0(window.top, 0);
}

function logWindows0(w, level) {
    GM_log('level ' + level + ' window URL: ' + w.location);
    for (var i = 0; i < w.frames.length; i++) {
        logWindows0(w.frames[i], level + 1);
    }
}

function mark() {
    // Return the mark time, expressed in seconds since Jan. 1, 1970.
    // If the mark isn't set return 0, equivalent to Jan. 1, 1970.
    return GM_getValue( 'mark', 0 );
}

function markString() {
    // Return the mark time as a date/time string.
    return Date(mark() * 1000);
}

function markNow() {
    // Return a mark time equivalent to right now, in seconds since Jan. 1, 1970
    return Math.floor(Date.now() / 1000);
}

function step() {
    // Return the stored step name.
    return GM_getValue( 'step', '' );
}

function nextStep( stepName ) {
    // Set the stored step name for the next page load.
    // Mark the time.
    GM_setValue('step', stepName);
    GM_setValue('mark', markNow());
}

function done(msg, type) {
    // Finished. Done. Clean up.
    // Include the optional message in the log.
    // type = 'silent' --> no alert
    //        'exception' --> alert as exception
    //        'stopping' --> alert before completion
    //        missing --> alert as normal finish
    var prefix;
    if (msg == undefined)
        msg = 'ALEKS data extraction done.';
    else if (type == undefined)
        msg = 'ALEKS data extraction done - ' + msg;
    else if (type == 'stopping')
        msg = 'ALEKS data extraction stopping - ' + msg;
    else if (type == 'exception')
        msg = 'Something unexpected happened, interrupting ALEKS data extraction: ' + msg;
    GM_log(msg);
    if (type == undefined || type != 'silent')
        alert(msg);
    GM_deleteValue('extract');
    GM_deleteValue('step');
    GM_deleteValue('mark');
    GM_deleteValue('teacher');
    GM_deleteValue('class');
    GM_deleteValue('student');
    GM_deleteValue('studentListIndex');
    GM_deleteValue('records');
}

var stepFunction = {
    // Map step names to step functions.
    // See STEP FUNCTIONS below.
    'captureTeacher': captureTeacher,
    'followReportingLink': followReportingLink,
    'followStudentReportLink': followStudentReportLink,
    'studentList': studentList,
    'studentRecord': studentRecord,
    'studentReport': studentReport,
    'extract': extract
};

var classList;
function createClassList() {
    // Create a list of teachers and classes from ALEK's main class menu,
    // a SELECT element containing teacher and class names.
    // Our global variable classMenu already references the menu (see extractTeacher, eg).
    // Populate the global variable classList and return it.
    classList = [{teacher: 'you', index: 1, classes: []}];  // default teacher record for current user
    // Enumerate teachers and classes from the selection menu.
    // OPTION elements in the selection menu:
    //   index 0, the first option = the menu caption --> skip
    //   value = 'no_submit' and blank text --> spacer, skip
    //   value = 'no_submit' and nonblank text --> teacher name, create new teacher record
    //   otherwise --> class name, add to current teacher's class list
    // Your (the current ALEKS user's) classes are listed first in the menu.
    // In previous ALEKS versions your classes weren't preceded by a 'no_submit' teacher name option.
    // In ALEKS 3.14, a 'no_submit' option with text "Your classes" appears before your classes.
    //
    // If the current ALEKS page is a class page, the teacher's name may occur
    // at the end of this class name in the selection menu. Remove it.
    var iOption;  // option collection index
    var option;   // option element we're processing
    for (iOption = 1; iOption < classMenu.options.length; iOption++) {
        option = classMenu.options.item(iOption);
        if (option.value.trim() == 'no_submit' && option.text.trim() == '')
            ;
        else if (option.value.trim() == 'no_submit') {
            // Teacher name
            if (/Your.classes/.test(option.text)) {
                classList.shift();  // remove default 'you' record
                classList.push({teacher: 'you', index: iOption, classes: []});
            } else {
                classList.push({teacher: option.text.trim(), index: iOption, classes: []});
            }
        }
        else {
            // Class name
            var className = option.text.trim();
            var classTeacherMatch = /(.*\S)\s*\((.*)\)\s*$/.exec(className);
            if (classTeacherMatch && classTeacherMatch[2] != 'with QuickTables') {
                // Drop "(teacher name)" from class name.
                className = classTeacherMatch[1];
            }
            classList[classList.length - 1].classes.push({name: className, index: iOption});
        }
    }
    //    // Display teacher/class list in the error console.
    //    for (var i = 0; i < classList.length; i++) {
    //        var sout = classList[i].teacher + ':';
    //        for (var j = 0; j < classList[i].classes.length; j++) {
    //            sout += ' "' + classList[i].classes[j].name + '"';
    //        }
    //        GM_log(sout);
    //    }
    return classList;
}

function selectTeacher(classList) {
    // Display a list of teachers from the class list and
    // return a teacher name.
    // If they cancel, return null.
    // If they don't select a teacher, or if they enter an invalid value,
    // throw an exception with an error message.
    var promptText = '';
    for (var i = 0; i < classList.length; i++) {
        if (i) promptText += "\n";
        promptText += (i < 9 ? ' ' : '') + (i + 1) + '  ' + classList[i].teacher;
    }
    promptText += "\n\nEnter a number:";
    var teacherNum;
    do
        teacherNum = prompt(promptText, '');
    while (teacherNum !== null && (teacherNum === '' || isNaN(teacherNum) || teacherNum < 1 || teacherNum > classList.length));
    if (teacherNum === null)
        return null;
    teacherNum = parseInt(teacherNum);
    return classList[teacherNum - 1].teacher;
}

function selectRecords() {
    // Prompt for which student records to extract:
    //   'mra' - most recent assessment
    //   'mrra' - most recent requested assessment
    //   'mrr' - most recent record, whether assessment or learning
    //   'since m/d/yyyy' - all assessment records after a given date
    // Return 'mra', 'mrra', 'mrr', or a Date, or null if they cancel the prompt.
    // Store valid selection in a persistent 'records' variable.
    var promptText = 'Enter one of the following codes to specify which student records to extract.';
    promptText += '\n\nmra: most recent assessment';
    promptText += '\nmrra: most recent requested assessment';
    promptText += '\nmrr: most recent record, whether assessment or learning';
    promptText += '\nsince m/d/yyyy: all assessment records since a given date';
    // Check their response against a regular expression.
    var validExp = /^\s*(mra|mrra|mrr|since +(\d{1,2})\/(\d{1,2})\/(\d{4}))\s*$/i;
    var validMatch;
    var response = 'mra';  // (default response)
    var validResponse;

    function valid() {
        // Return true or false.
        // If valid, trim and lowercase the response variable.
        if (validMatch = validExp.exec(response))
            return response = validMatch[1].toLowerCase();
        else
            return false;
    }

    // Prompt until they get it right or cancel (null response).
    do
        response = prompt(promptText, response);
    while ( !(response === null || valid()) );
    if (response) GM_setValue('records', response);
    GM_log('records ' + response);
    return response;
}

function extractThisClass() {
    // Alternative entry point, initiated via the Greasemonkey menu.
    // Extract data for the class currently displayed in ALEKS.
    var className;
    var classNameAndTeacherExp = /^(.*\S)\s*\([^)]*\)$/;  // class name with " (teacher name)"
    var classNameAndTeacherMatch;
    try {
        nextStep('midstep');  // catch unexpected page loads
        GM_setValue('extract', 'class');  // remember we're extracting just this class
        classMenu = findEmbeddedNode(document, '/descendant::form/descendant::select[attribute::name="id_main_class"]');
        if (!classMenu) throw('I cannot find the ALEKS class menu here!');
        className = classMenu.options.item(classMenu.selectedIndex).text.trim();
        classNameAndTeacherMatch = classNameAndTeacherExp.exec(className);
        if (classNameAndTeacherMatch) {
            className = classNameAndTeacherMatch[1];  // drop teacher name
        }
        GM_setValue('class', className);
        GM_log('class ' + className);
        if (!selectRecords())
            return;  // they cancelled
        captureTeacher();
    }
    catch (exception) {
        done(exception, 'exception');
    }
}

function extractTeacher() {
    // This begins ALEKS data extraction for a teacher, initiated via the Greasemonkey menu.
    // Navigate ALEKS web pages for the teacher's classes, reports, and students
    // to extract student progress data.
    // Step functions and other functions that this function calls should ordinarily
    // handle errors by throwing a string-valued exception, to be handled by this function.
    try {
        nextStep('midstep');  // identify unexpected page loads
        GM_setValue('extract', 'teacher');  // remember we're extracting all classes for a teacher
        // Find ALEKS's class selection menu.
        // (We aren't initiated through the wakeUp() sequence so we have to do this ourselves.)
        classMenu = findEmbeddedNode(document, '/descendant::form/descendant::select[attribute::name="id_main_class"]');
        // [Restructure this: first find our target frame, then find the menu.]
        if (!classMenu) throw('I cannot find the ALEKS teacher/class menu here!');

        createClassList();
        var teacher;
        if (teacher = selectTeacher(classList)) {  // they selected a teacher
            GM_setValue('teacher', teacher);
            GM_log('teacher ' + teacher);
            // Select the teacher's first class (load the class page in ALEKS).
            var course = classList.filter(function(e) {return e.teacher == teacher})[0].classes[0];
            GM_setValue('class', course.name);
            GM_log('class ' + course.name);
            if (selectRecords()) {
                nextStep( 'captureTeacher' );
                classMenu.value = classMenu.options.item(course.index).value;
                classMenu.form.submit();
            } else {  // they cancelled during record selection
                done('No record type selected', 'stopping');
            }
        } else {  // they cancelled during teacher selection
            done('No teacher selected', 'stopping');
        } 
    }
    catch (exception) {
        done(exception, 'exception');
    }
}

function findEmbeddedNodes(el, xpath) {
    // Return a node set snapshot if we find nodes matching the xpath expression in any nested frame.
    // Return null if we don't find any.
    // The initial element el is usually the top-level document.
    // Access nodes in the snapshot s as s.snapshotItem[0] through s.snapshotItem[s.snapshotLength - 1].
    var xpathResult;

    //GM_log('Called with ' + el);
    if (!el) return null;  // end of a branch
    if (el.nodeType == 9) {  // document
        //GM_log('document');
        return findEmbeddedNodes(el.body, xpath);  // search the body
    }
    //GM_log(el.tagName);
    switch (el.tagName) {
    case 'FRAMESET':
        // Search depth-first.
        return findEmbeddedNodes(el.firstElementChild, xpath) || findEmbeddedNodes(el.nextElementSibling, xpath);
    case 'FRAME':
        // Search this frame's document, then on to the next frame.
        return findEmbeddedNodes(el.contentDocument, xpath) || findEmbeddedNodes(el.nextElementSibling, xpath);
    case 'BODY':
        // Look here.
        xpathResult = el.ownerDocument.evaluate(xpath, el.ownerDocument, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
        return xpathResult.snapshotLength ? xpathResult : null;
    }
    //GM_log('Fell through');
    return null;
}

function findEmbeddedNode(el, xpath) {
    var set = findEmbeddedNodes(el, xpath);
    return set ? set.snapshotItem(0) : null;
}


// STEP FUNCTIONS

function captureTeacher() {
    // Capture the teacher name from the class page title.
    var className = GM_getValue('class', '');
    var classTitle;
    nextStep('midstep');  // catch unexpected page loads
    // Search for the class page title.
    var titleXPath = 'descendant::text()[starts-with(string(.),"' + className.replace('"', '&quot;') + '")]';
    //GM_log('page title XPath: ' + titleXPath);
    classTitle = findEmbeddedNode(document, titleXPath);
    if (!classTitle) throw('I cannot find a class title here!');
    classTitle = classTitle.nodeValue;
    //GM_log('page title search result: #' + classTitle + '#');
    titleMatch = /(.*\S)\s*\((.*)\)\s*$/.exec(classTitle);
    if (!titleMatch) throw('I cannot find the teacher name in the class page title!');
    var teacher = titleMatch[2];
    GM_log('Teacher from class page title: ' + teacher);
    GM_setValue('teacher', teacher);
    followReportingLink();
}

function nextClass() {
    // For a teacher extraction, move to the next class, if any.
    // For a class extraction, just quit.
    var extract = GM_getValue('extract', '');
    if (extract == 'class') {
        done('finished student list');
    } else {
        var lastClass = GM_getValue('class', '');
        var teacher = GM_getValue('teacher', '');
        findClassMenu0();  // set classMenu
        if (classMenu) {
            createClassList();
            var teacherRecord = classList.filter(function(r) {return r.teacher == teacher})[0];
            var classes = teacherRecord.classes;
            var iClass;
            for (iClass = 0; classes[iClass].name != lastClass; iClass++) ;
            if (++iClass == classes.length) {
                done('finished classes for ' + teacher);
            } else {
                GM_setValue('class', classes[iClass].name);
                nextStep('followReportingLink');
                classMenu.value = classMenu.options.item(classes[iClass].index).value;
                classMenu.form.submit();
            }
        } else {  // class menu not found
            throw('I cannot find the class menu here!');
        }
    }
}

function followReportingLink() {
    // Find and follow the "Reporting" link, unless we've finished.
    if (GM_getValue('step', '')) {
        var link = findEmbeddedNode(document, 'descendant::a[string(.)="Reporting"]');
        if (!link) throw('I cannot find the Reporting link here!');
        nextStep( 'followStudentReportLink' );
        location.assign(link.href);
    }
}

function followStudentReportLink() {
    // Find and follow the "Massachusetts standards report for a single student:" link.
    var linkText = 'Massachusetts standards report for a single student:';
    var link = findEmbeddedNode(document, 'descendant::a[string(.)="' + linkText + '"]');
    if (link) {
        nextStep( 'studentList' );
        location.assign(link.href);
    } else {
        // See if unlinked report title is present, which occurs for a class with no students.
        link = findEmbeddedNode(document, 'descendant::*[string(.)="' + linkText + '"]');
        if (link)
            nextClass();
        else
            throw('I cannot find the "Massachusetts standards report for a single student:" link here!');
    }
}

function studentList() {
    // Collect data for each student in the report list.
    var snapshot;  // UL/LI/A student list elements
    var studentListIndex;  // index into snapshot
    var student;  // student name
    // First make sure we're looking at student names and not logins or student IDs.
    var nameLink = findEmbeddedNode(document, 'descendant::a[string(.)="Name"]');
    if (nameLink) {
        nextStep('studentList');  // return to the same step
        location.assign(nameLink.href);
    }
    else {
        snapshot = findEmbeddedNodes(document, 'descendant::ul/child::li/descendant::a');
        GM_log('student snapshot length ' + snapshot.snapshotLength);
        if (snapshot) {
            // Process students one at a time.
            studentListIndex = GM_getValue('studentListIndex', -1) + 1;
            if (studentListIndex < snapshot.snapshotLength) {
                GM_setValue('studentListIndex', studentListIndex);
                GM_log('studentListIndex ' + studentListIndex);
                student = snapshot.snapshotItem(studentListIndex).textContent.trim();
                GM_setValue('student', student);
                GM_log('student ' + student);
                nextStep('studentRecord');
                GM_log(snapshot.snapshotItem(studentListIndex).href);
                location.assign(snapshot.snapshotItem(studentListIndex).href);  // follow link to student report
            } else {
                GM_deleteValue('student');
                GM_deleteValue('studentListIndex');
                nextClass();
            }
        }
    }
}

function studentRecord() {
    // Make sure we're looking at the right student record.
    var doc = classMenu.ownerDocument;
    var recordMenu;  // a SELECT element
    var record = [];  // array of record names from menu
    var whichRecord = GM_getValue('records', 'mra');
    var i;  // loop index
    var mrraExp = /requested|comp/i;
    var match;
    var selectIndex;

    // Find the record menu.
    // If there is no record menu, student evidently has no record.
    //   --> Empty mastery results are displayed, so we can continue.
    var xPath = 'descendant::select[@id="id_record"]';
    var xPathResult = doc.evaluate(xPath, doc, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null);
    if (recordMenu = xPathResult.singleNodeValue) {
        // We found a record menu. Parse and interpret it.
        for (i = 0; i < recordMenu.options.length; i++) {
            record.push(recordMenu.options.item(i).text);
        }
        GM_log('student record menu:\n' + record.join('\n'));
        
        switch (whichRecord) {
            case 'mra':
                // most recent assessment
                break;
            case 'mrra':
                // most recent requested assessment
                for (i = 0; i < recordMenu.options.length; i++) {
                    if (match = mrraExp.test(recordMenu.options.item(i).text)) {
                        selectIndex = i;
                        break;
                    }
                }
                break;
            case 'mrr':
                // most recent record
                break;
            default:
                // "since m/d/yyyy"
        }
    }
    if (selectIndex !== undefined && recordMenu.selectedIndex != selectIndex) {
        nextStep('studentReport');
        recordMenu.selectedIndex = selectIndex;
        recordMenu.form.submit();
    }
    else
        studentReport();
}

function studentReport() {
    // Process a student report page.
    var doc = classMenu.ownerDocument;
    var linkXPath = 'descendant::a[contains(string(.), "Open All")]';
    var linkXPathResult = doc.evaluate(linkXPath, doc, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null);
    var link = linkXPathResult.singleNodeValue;
    if (!link) throw('I cannot find the "[Open All]" link here!');
    nextStep('extract');
    window.location.assign(link.href);
}

function extract() {
    // Extract student data from the expanded student report on screen.
    parseAndPost();
    // If problems came up we called done() and cleared our persistent values,
    // so we won't find anything more to do.
}

function continueAfterExtract() {
    // Called after extract process finishes waiting for post to complete.
    followReportingLink();
}
